opencode-avatar 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ import { type Plugin } from "@opencode-ai/plugin";
2
+ export declare const AvatarPlugin: Plugin;
package/dist/index.js ADDED
@@ -0,0 +1,295 @@
1
+ // @bun
2
+ // index.ts
3
+ import { spawn } from "child_process";
4
+ import * as path from "path";
5
+ import * as http from "http";
6
+ import * as fs from "fs";
7
+ var __dirname = "/var/home/wizard/opencode-avatar";
8
+ var AVATAR_DIR = __dirname;
9
+ var DEFAULT_AVATAR = "avatar.png";
10
+ var THINKING_PROMPT = "thinking hard";
11
+ var AVATAR_PORT = 47291;
12
+ function getToolPrompt(toolName, toolDescription) {
13
+ if (toolDescription) {
14
+ const shortDesc = toolDescription.split(".")[0].substring(0, 50);
15
+ return `${toolName} - ${shortDesc}`;
16
+ }
17
+ return toolName;
18
+ }
19
+ var electronProcess = null;
20
+ var currentAvatar = DEFAULT_AVATAR;
21
+ var isThinking = false;
22
+ var isToolActive = false;
23
+ var isShuttingDown = false;
24
+ var heartbeatInterval = null;
25
+ function sendHeartbeat() {
26
+ const req = http.request({
27
+ hostname: "localhost",
28
+ port: AVATAR_PORT,
29
+ path: "/heartbeat",
30
+ method: "POST",
31
+ timeout: 1000
32
+ }, () => {});
33
+ req.on("error", () => {});
34
+ req.on("timeout", () => req.destroy());
35
+ req.end();
36
+ }
37
+ function startHeartbeat() {
38
+ if (heartbeatInterval)
39
+ return;
40
+ sendHeartbeat();
41
+ heartbeatInterval = setInterval(sendHeartbeat, 500);
42
+ }
43
+ function stopHeartbeat() {
44
+ if (heartbeatInterval) {
45
+ clearInterval(heartbeatInterval);
46
+ heartbeatInterval = null;
47
+ }
48
+ }
49
+ async function isAvatarServerRunning() {
50
+ return new Promise((resolve) => {
51
+ const req = http.request({
52
+ hostname: "localhost",
53
+ port: AVATAR_PORT,
54
+ path: "/health",
55
+ method: "GET",
56
+ timeout: 1000
57
+ }, (res) => {
58
+ resolve(res.statusCode === 200);
59
+ });
60
+ req.on("error", () => resolve(false));
61
+ req.on("timeout", () => {
62
+ req.destroy();
63
+ resolve(false);
64
+ });
65
+ req.end();
66
+ });
67
+ }
68
+ async function sendShutdownCommand() {
69
+ return new Promise((resolve) => {
70
+ const req = http.request({
71
+ hostname: "localhost",
72
+ port: AVATAR_PORT,
73
+ path: "/shutdown",
74
+ method: "POST",
75
+ timeout: 2000
76
+ }, (res) => {
77
+ resolve(res.statusCode === 200);
78
+ });
79
+ req.on("error", () => resolve(false));
80
+ req.on("timeout", () => {
81
+ req.destroy();
82
+ resolve(false);
83
+ });
84
+ req.end();
85
+ });
86
+ }
87
+ function promptToFilename(prompt, toolName) {
88
+ const baseName = toolName || prompt;
89
+ return "avatar_" + baseName.toLowerCase().replace(/[^a-z0-9\s]/g, "").replace(/\s+/g, "_").substring(0, 50) + ".png";
90
+ }
91
+ function getAvatarPath(prompt, toolName) {
92
+ if (!prompt) {
93
+ return path.join(AVATAR_DIR, DEFAULT_AVATAR);
94
+ }
95
+ const filename = promptToFilename(prompt, toolName);
96
+ const avatarPath = path.join(AVATAR_DIR, filename);
97
+ if (fs.existsSync(avatarPath)) {
98
+ return avatarPath;
99
+ }
100
+ return path.join(AVATAR_DIR, DEFAULT_AVATAR);
101
+ }
102
+ async function startElectron(avatarPath) {
103
+ if (isShuttingDown) {
104
+ return;
105
+ }
106
+ const alreadyRunning = await isAvatarServerRunning();
107
+ if (alreadyRunning) {
108
+ return;
109
+ }
110
+ if (electronProcess) {
111
+ try {
112
+ electronProcess.kill("SIGKILL");
113
+ } catch (e) {}
114
+ electronProcess = null;
115
+ }
116
+ const electronPath = path.join(AVATAR_DIR, "node_modules", ".bin", "electron");
117
+ const electronEntry = path.join(AVATAR_DIR, "dist", "electron.js");
118
+ const child = spawn(electronPath, [electronEntry, "--avatar", avatarPath, "--avatar-port", String(AVATAR_PORT)], {
119
+ cwd: AVATAR_DIR,
120
+ stdio: ["ignore", "pipe", "pipe"],
121
+ detached: false
122
+ });
123
+ child.stdout?.on("data", (data) => {});
124
+ child.stderr?.on("data", (data) => {});
125
+ child.on("error", (err) => {});
126
+ child.on("exit", (code, signal) => {
127
+ electronProcess = null;
128
+ });
129
+ electronProcess = child;
130
+ }
131
+ async function shutdownElectron() {
132
+ isShuttingDown = true;
133
+ stopHeartbeat();
134
+ const httpShutdown = await sendShutdownCommand();
135
+ if (httpShutdown) {
136
+ electronProcess = null;
137
+ return;
138
+ }
139
+ if (electronProcess) {
140
+ const pid = electronProcess.pid;
141
+ try {
142
+ electronProcess.kill("SIGKILL");
143
+ } catch (e) {}
144
+ electronProcess = null;
145
+ }
146
+ }
147
+ process.on("exit", () => {
148
+ shutdownElectron();
149
+ });
150
+ process.on("beforeExit", () => {
151
+ shutdownElectron();
152
+ });
153
+ process.on("SIGINT", () => {
154
+ shutdownElectron();
155
+ });
156
+ process.on("SIGTERM", () => {
157
+ shutdownElectron();
158
+ });
159
+ process.on("uncaughtException", (err) => {
160
+ shutdownElectron();
161
+ });
162
+ async function setAvatarViaHttp(prompt, toolName) {
163
+ const avatarPath = getAvatarPath(prompt, toolName);
164
+ if (avatarPath === currentAvatar) {
165
+ return;
166
+ }
167
+ currentAvatar = avatarPath;
168
+ return new Promise((resolve) => {
169
+ const req = http.request({
170
+ hostname: "localhost",
171
+ port: AVATAR_PORT,
172
+ path: "/set-avatar",
173
+ method: "POST",
174
+ headers: { "Content-Type": "application/json" }
175
+ }, () => {
176
+ resolve();
177
+ });
178
+ req.on("error", (err) => {
179
+ resolve();
180
+ });
181
+ req.write(JSON.stringify({ avatarPath }));
182
+ req.end();
183
+ });
184
+ }
185
+ var AvatarPlugin = async ({ client }) => {
186
+ const getToolDescription = (toolName) => {
187
+ try {
188
+ const toolInfo = client.tools?.[toolName];
189
+ if (toolInfo?.description) {
190
+ return toolInfo.description;
191
+ }
192
+ const tools = client.getTools?.() || {};
193
+ if (tools[toolName]?.description) {
194
+ return tools[toolName].description;
195
+ }
196
+ } catch (e) {}
197
+ return;
198
+ };
199
+ const showInfoToast = (message) => {
200
+ client.tui.showToast({
201
+ body: {
202
+ message,
203
+ variant: "info"
204
+ }
205
+ });
206
+ };
207
+ const showErrorToast = (message) => {
208
+ client.tui.showToast({
209
+ body: {
210
+ message,
211
+ variant: "error"
212
+ }
213
+ });
214
+ };
215
+ async function requestAvatarGeneration(prompt, showToasts = true, toolName) {
216
+ if (showToasts) {
217
+ showInfoToast(`Generating avatar: ${prompt}`);
218
+ }
219
+ return new Promise((resolve, reject) => {
220
+ const req = http.request({
221
+ hostname: "localhost",
222
+ port: AVATAR_PORT,
223
+ path: "/generate-avatar",
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/json" }
226
+ }, (res) => {
227
+ let data = "";
228
+ res.on("data", (chunk) => data += chunk);
229
+ res.on("end", () => {
230
+ if (!showToasts) {
231
+ isToolActive = false;
232
+ }
233
+ if (res.statusCode === 200) {
234
+ if (showToasts) {
235
+ showInfoToast(`Avatar ready: ${prompt}`);
236
+ }
237
+ setAvatarViaHttp(prompt, toolName);
238
+ resolve();
239
+ } else {
240
+ if (showToasts) {
241
+ showErrorToast(`Avatar failed: ${data}`);
242
+ }
243
+ reject(new Error(`Failed to generate avatar: ${data}`));
244
+ }
245
+ });
246
+ });
247
+ req.on("error", (err) => {
248
+ if (showToasts) {
249
+ showErrorToast(`Avatar generation error: ${err.message}`);
250
+ }
251
+ reject(err);
252
+ });
253
+ req.write(JSON.stringify({ prompt }));
254
+ req.end();
255
+ });
256
+ }
257
+ try {
258
+ const initialAvatar = getAvatarPath();
259
+ await startElectron(initialAvatar);
260
+ startHeartbeat();
261
+ showInfoToast("Avatar started");
262
+ } catch (error) {
263
+ const message = error instanceof Error ? error.message : "Unknown error";
264
+ showErrorToast(`Failed to start avatar: ${message}`);
265
+ }
266
+ return {
267
+ "chat.message": async (input, output) => {
268
+ const userMessage = output.parts.find((part) => part.type === "text" && part.messageID === input.messageID);
269
+ if (userMessage?.text) {}
270
+ if (userMessage?.text && !isThinking) {
271
+ isThinking = true;
272
+ await requestAvatarGeneration(THINKING_PROMPT);
273
+ }
274
+ },
275
+ "tool.execute.before": async (input) => {
276
+ const toolName = input.tool;
277
+ const toolDescription = getToolDescription(toolName);
278
+ const prompt = getToolPrompt(toolName, toolDescription);
279
+ isToolActive = true;
280
+ requestAvatarGeneration(prompt, false, toolName).catch((err) => {
281
+ isToolActive = false;
282
+ });
283
+ },
284
+ event: async ({ event }) => {
285
+ if (event.type === "session.idle" && (isThinking || isToolActive)) {
286
+ isThinking = false;
287
+ isToolActive = false;
288
+ await setAvatarViaHttp();
289
+ }
290
+ }
291
+ };
292
+ };
293
+ export {
294
+ AvatarPlugin
295
+ };
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "opencode-avatar",
3
+ "version": "0.1.0",
4
+ "description": "Dynamic desktop avatar plugin for OpenCode that reacts to your coding activities",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "avatar.png",
17
+ "README.md"
18
+ ],
19
+ "scripts": {
20
+ "build": "bun build ./index.ts --outdir ./dist --target bun && bun build ./electron.ts --outdir ./dist --target node --external electron && bun run build:types",
21
+ "build:types": "tsc --emitDeclarationOnly",
22
+ "start": "bun run build && electron dist/electron.js",
23
+ "start:thinking": "bun run build && electron dist/electron.js --avatar avatar_thinking_hard.png",
24
+ "prepublishOnly": "bun run build"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^25.0.10",
28
+ "electron": "^28.0.0",
29
+ "typescript": "^5.9.3"
30
+ },
31
+ "peerDependencies": {
32
+ "@opencode-ai/plugin": "^1.1.25"
33
+ },
34
+ "dependencies": {
35
+ "dotenv": "^17.2.3"
36
+ },
37
+ "keywords": [
38
+ "opencode",
39
+ "plugin",
40
+ "avatar",
41
+ "desktop",
42
+ "ai",
43
+ "coding",
44
+ "assistant"
45
+ ],
46
+ "author": "OpenCode Community",
47
+ "license": "MIT",
48
+ "repository": {
49
+ "type": "git",
50
+ "url": "git+https://github.com/opencode-ai/avatar-plugin.git"
51
+ },
52
+ "homepage": "https://github.com/opencode-ai/avatar-plugin#readme",
53
+ "bugs": {
54
+ "url": "https://github.com/opencode-ai/avatar-plugin/issues"
55
+ }
56
+ }