opencode-buddy 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/src/index.js ADDED
@@ -0,0 +1,302 @@
1
+ // Main runtime. Wires together state, persistence, TUI, tmux polling.
2
+ //
3
+ // Usage from bin/opencode-buddy.js:
4
+ // await runInteractive() // for `opencode-buddy start`
5
+ // await runCommand(args) // for all other subcommands
6
+
7
+ import * as persistence from "./persistence.js";
8
+ import { createTui } from "./tui.js";
9
+ import {
10
+ tick,
11
+ feed,
12
+ play,
13
+ rest,
14
+ rename,
15
+ switchSpecies,
16
+ celebrate,
17
+ scared,
18
+ working,
19
+ deriveState,
20
+ maybeLevelUp,
21
+ describe as describeState,
22
+ DEFAULT_STATE,
23
+ } from "./buddy/state.js";
24
+ import { SPECIES } from "./buddy/species.js";
25
+ import {
26
+ inTmux,
27
+ currentPaneId,
28
+ splitRight,
29
+ captureMainPane,
30
+ inferActivity,
31
+ killBuddyPane,
32
+ } from "./tmux.js";
33
+
34
+ const TICK_MS = 1000;
35
+ const SAVE_EVERY_MS = 5000;
36
+
37
+ const HINT = `keys: f feed · p play · r rename · s switch · z rest · n hatch · q quit`;
38
+
39
+ export async function loadOrInit() {
40
+ const existing = await persistence.load();
41
+ if (existing) return existing;
42
+ const fresh = { ...DEFAULT_STATE, lastTick: Date.now() };
43
+ await persistence.save(fresh);
44
+ return fresh;
45
+ }
46
+
47
+ export async function runInteractive({ autoSplit = true } = {}) {
48
+ if (!inTmux()) {
49
+ throw new Error(
50
+ "opencode-buddy start must run inside a tmux session. " +
51
+ "Start tmux first, then run: opencode-buddy start",
52
+ );
53
+ }
54
+
55
+ let state = await loadOrInit();
56
+ let buddyPane = null;
57
+ if (autoSplit) {
58
+ buddyPane = splitRight(
59
+ `cd ${process.cwd()} && exec ${process.execPath} ${process.argv[1]} render`,
60
+ 28,
61
+ );
62
+ }
63
+
64
+ const tui = createTui({
65
+ onKey: (key) => {
66
+ switch (key) {
67
+ case "f":
68
+ state = feed(state);
69
+ flash("yum!");
70
+ break;
71
+ case "p":
72
+ state = play(state);
73
+ flash("hehe!");
74
+ break;
75
+ case "z":
76
+ state = rest(state);
77
+ flash("zzz...");
78
+ break;
79
+ case "n":
80
+ state = { ...DEFAULT_STATE, lastTick: Date.now() };
81
+ flash("new buddy!");
82
+ break;
83
+ case "r": {
84
+ // simple prompt via stdout
85
+ process.stdout.write(`\x1b[2J\x1b[Hnew name: `);
86
+ let name = "";
87
+ const onData = (b) => {
88
+ const s = b.toString("utf8");
89
+ if (s === "\n" || s === "\r" || s === "\u0003") {
90
+ process.stdin.off("data", onData);
91
+ if (name) state = rename(state, name);
92
+ flash(name ? `hi ${name}!` : "ok");
93
+ } else if (s === "\u007f" || s === "\b") {
94
+ name = name.slice(0, -1);
95
+ } else {
96
+ name += s.replace(/[\r\n]/g, "");
97
+ }
98
+ };
99
+ process.stdin.on("data", onData);
100
+ break;
101
+ }
102
+ case "s": {
103
+ // cycle species
104
+ const idx = SPECIES.indexOf(state.species);
105
+ const next = SPECIES[(idx + 1) % SPECIES.length];
106
+ state = switchSpecies(state, next);
107
+ flash(`now ${next}`);
108
+ break;
109
+ }
110
+ case "q":
111
+ case "ctrl-c":
112
+ cleanup();
113
+ process.exit(0);
114
+ break;
115
+ default:
116
+ // ignore
117
+ break;
118
+ }
119
+ },
120
+ });
121
+
122
+ let flashUntil = 0;
123
+ let flashText = "";
124
+ function flash(text, ms = 1500) {
125
+ flashText = text;
126
+ flashUntil = Date.now() + ms;
127
+ }
128
+
129
+ function buildHint() {
130
+ if (Date.now() < flashUntil) return `${flashText} · ${HINT}`;
131
+ return HINT;
132
+ }
133
+
134
+ let lastActivity = "unknown";
135
+ let lastPoll = 0;
136
+ let lastSave = 0;
137
+ let running = true;
138
+
139
+ async function loop() {
140
+ while (running) {
141
+ const now = Date.now();
142
+
143
+ // 1. tick attributes for time decay
144
+ state = tick(state, now);
145
+
146
+ // 2. capture tmux main pane every 2s for activity inference
147
+ if (now - lastPoll > 2000) {
148
+ lastPoll = now;
149
+ const text = captureMainPane(60);
150
+ const activity = inferActivity(text);
151
+ if (activity !== lastActivity) {
152
+ lastActivity = activity;
153
+ if (activity === "working") state = working(state);
154
+ else if (activity === "error") state = scared(state);
155
+ else if (activity === "idle")
156
+ state = celebrate(state, 1500);
157
+ }
158
+ }
159
+
160
+ // 3. derive final state (transient overrides first, then attributes)
161
+ state = deriveState(state, now);
162
+
163
+ // 4. level up check
164
+ state = maybeLevelUp(state);
165
+
166
+ // 5. render
167
+ tui.render(state, buildHint());
168
+
169
+ // 6. persist periodically
170
+ if (now - lastSave > SAVE_EVERY_MS) {
171
+ lastSave = now;
172
+ await persistence.save(state).catch(() => {});
173
+ }
174
+
175
+ await new Promise((r) => setTimeout(r, TICK_MS));
176
+ }
177
+ }
178
+
179
+ function cleanup() {
180
+ running = false;
181
+ tui.stop();
182
+ if (buddyPane) killBuddyPane(buddyPane);
183
+ }
184
+
185
+ process.on("SIGINT", () => {
186
+ cleanup();
187
+ process.exit(0);
188
+ });
189
+ process.on("SIGTERM", () => {
190
+ cleanup();
191
+ process.exit(0);
192
+ });
193
+
194
+ tui.start();
195
+ await loop();
196
+ }
197
+
198
+ // Headless command runner: feed, play, rename, switch, hatch, stats, notify
199
+ export async function runCommand(argv) {
200
+ const [cmd, ...rest] = argv;
201
+ let state = await loadOrInit();
202
+
203
+ switch (cmd) {
204
+ case "hatch":
205
+ case "reset": {
206
+ const species = rest[0] && SPECIES.includes(rest[0]) ? rest[0] : "duck";
207
+ state = {
208
+ ...DEFAULT_STATE,
209
+ species,
210
+ name: rest[1] || DEFAULT_STATE.name,
211
+ lastTick: Date.now(),
212
+ };
213
+ await persistence.save(state);
214
+ console.log(`Hatched a new ${state.species} named ${state.name}.`);
215
+ break;
216
+ }
217
+ case "feed":
218
+ state = feed(state);
219
+ await persistence.save(state);
220
+ console.log(`Fed ${state.name}. hunger -> ${Math.floor(state.hunger)}`);
221
+ break;
222
+ case "play":
223
+ state = play(state);
224
+ await persistence.save(state);
225
+ console.log(
226
+ `Played with ${state.name}. happiness -> ${Math.floor(state.happiness)}`,
227
+ );
228
+ break;
229
+ case "rest":
230
+ state = rest(state);
231
+ await persistence.save(state);
232
+ console.log(`${state.name} took a nap.`);
233
+ break;
234
+ case "rename": {
235
+ const name = rest.join(" ").trim();
236
+ if (!name) {
237
+ console.error("usage: opencode-buddy rename <name>");
238
+ process.exit(2);
239
+ }
240
+ state = rename(state, name);
241
+ await persistence.save(state);
242
+ console.log(`Renamed to ${state.name}.`);
243
+ break;
244
+ }
245
+ case "switch": {
246
+ const species = rest[0];
247
+ if (!species || !SPECIES.includes(species)) {
248
+ console.error(`species must be one of: ${SPECIES.join(", ")}`);
249
+ process.exit(2);
250
+ }
251
+ state = switchSpecies(state, species);
252
+ await persistence.save(state);
253
+ console.log(`${state.name} transformed into a ${species}.`);
254
+ break;
255
+ }
256
+ case "stats":
257
+ console.log(describeState(state));
258
+ break;
259
+ case "notify": {
260
+ const event = rest[0];
261
+ if (event === "working" || event === "start")
262
+ state = working(state);
263
+ else if (event === "done" || event === "celebrate")
264
+ state = celebrate(state);
265
+ else if (event === "error" || event === "scared")
266
+ state = scared(state);
267
+ else {
268
+ console.error(
269
+ "usage: opencode-buddy notify <working|done|error>",
270
+ );
271
+ process.exit(2);
272
+ }
273
+ await persistence.save(state);
274
+ console.log(`Notified buddy: ${event}`);
275
+ break;
276
+ }
277
+ case "path":
278
+ console.log(persistence.pathForDisplay());
279
+ break;
280
+ default:
281
+ printUsage();
282
+ process.exit(cmd ? 2 : 0);
283
+ }
284
+ }
285
+
286
+ function printUsage() {
287
+ console.log(`opencode-buddy — a virtual pet for your tmux session
288
+
289
+ Usage:
290
+ opencode-buddy start Launch interactive buddy in a tmux side pane
291
+ opencode-buddy hatch [species] [name] Start a brand new buddy (resets state)
292
+ opencode-buddy feed Feed your buddy
293
+ opencode-buddy play Play with your buddy
294
+ opencode-buddy rest Let your buddy rest
295
+ opencode-buddy rename <name> Give your buddy a new name
296
+ opencode-buddy switch <species> Change species (${SPECIES.join(", ")})
297
+ opencode-buddy notify <event> Notify buddy of an opencode event
298
+ (working | done | error)
299
+ opencode-buddy stats Print current buddy stats
300
+ opencode-buddy path Print path to state file
301
+ `);
302
+ }
package/src/install.js ADDED
@@ -0,0 +1,157 @@
1
+ // One-shot installer. Writes the /buddy slash command markdown file
2
+ // and adds this package to opencode.json's plugin list so opencode will
3
+ // bun-install it on the next startup.
4
+ //
5
+ // Re-runnable: safely no-ops if everything is already in place.
6
+
7
+ import { promises as fs } from "node:fs";
8
+ import path from "node:path";
9
+ import os from "node:os";
10
+
11
+ const OPENCODE_DIR = path.join(os.homedir(), ".config", "opencode");
12
+ const COMMANDS_DIR = path.join(OPENCODE_DIR, "commands");
13
+ const COMMAND_FILE = path.join(COMMANDS_DIR, "buddy.md");
14
+ const CONFIG_FILE = path.join(OPENCODE_DIR, "opencode.json");
15
+
16
+ const PLUGIN_NAME = "opencode-buddy";
17
+
18
+ const COMMAND_BODY = `---
19
+ description: Interact with your virtual buddy companion.
20
+ ---
21
+
22
+ The user typed \`/buddy\`. Use the \`buddy\` tool to respond.
23
+
24
+ If the user typed \`/buddy\` with no arguments, call the tool with
25
+ \`action: "status"\`. If they passed sub-arguments like \`/buddy feed\`
26
+ or \`/buddy switch dragon\`, map them to the tool's \`action\`,
27
+ \`species\`, and \`name\` arguments accordingly. Examples:
28
+
29
+ /buddy -> buddy({ action: "status" })
30
+ /buddy feed -> buddy({ action: "feed" })
31
+ /buddy play -> buddy({ action: "play" })
32
+ /buddy switch cat -> buddy({ action: "switch", species: "cat" })
33
+ /buddy rename Mia -> buddy({ action: "rename", name: "Mia" })
34
+ /buddy hatch dragon Sparky -> buddy({ action: "hatch", species: "dragon", name: "Sparky" })
35
+ /buddy ascii -> buddy({ action: "ascii" })
36
+
37
+ Always show the tool's returned text to the user. If the user just wants
38
+ to see the buddy, call \`action: "ascii"\` to render the current art in a
39
+ code block.
40
+ `;
41
+
42
+ export async function install() {
43
+ const steps = [];
44
+ const errors = [];
45
+
46
+ // 1. Write the slash command file
47
+ try {
48
+ await fs.mkdir(COMMANDS_DIR, { recursive: true });
49
+ const existing = await fs.readFile(COMMAND_FILE, "utf8").catch(() => null);
50
+ if (existing === COMMAND_BODY) {
51
+ steps.push(`= ${COMMAND_FILE} (unchanged)`);
52
+ } else {
53
+ await fs.writeFile(COMMAND_FILE, COMMAND_BODY);
54
+ steps.push(`+ ${COMMAND_FILE}`);
55
+ }
56
+ } catch (err) {
57
+ errors.push(`failed to write ${COMMAND_FILE}: ${err.message}`);
58
+ }
59
+
60
+ // 2. Patch opencode.json
61
+ try {
62
+ let config = {};
63
+ try {
64
+ const raw = await fs.readFile(CONFIG_FILE, "utf8");
65
+ config = JSON.parse(raw);
66
+ } catch (err) {
67
+ if (err.code !== "ENOENT") throw err;
68
+ }
69
+
70
+ let pluginList = config.plugin;
71
+ if (pluginList == null) {
72
+ pluginList = [];
73
+ config.plugin = pluginList;
74
+ } else if (typeof pluginList === "string") {
75
+ pluginList = [pluginList];
76
+ config.plugin = pluginList;
77
+ } else if (!Array.isArray(pluginList)) {
78
+ errors.push(
79
+ `${CONFIG_FILE} has an unexpected "plugin" field type; expected array.`,
80
+ );
81
+ pluginList = [];
82
+ }
83
+
84
+ if (!pluginList.includes(PLUGIN_NAME)) {
85
+ pluginList.push(PLUGIN_NAME);
86
+ config.plugin = pluginList;
87
+ const tmp = CONFIG_FILE + ".tmp";
88
+ await fs.writeFile(tmp, JSON.stringify(config, null, 2));
89
+ await fs.rename(tmp, CONFIG_FILE);
90
+ steps.push(`~ ${CONFIG_FILE} (added "${PLUGIN_NAME}")`);
91
+ } else {
92
+ steps.push(`= ${CONFIG_FILE} (already lists "${PLUGIN_NAME}")`);
93
+ }
94
+ } catch (err) {
95
+ errors.push(`failed to patch ${CONFIG_FILE}: ${err.message}`);
96
+ }
97
+
98
+ // 3. Report
99
+ console.log("opencode-buddy install");
100
+ console.log("------------------------");
101
+ for (const s of steps) console.log(` ${s}`);
102
+ if (errors.length) {
103
+ console.log("\nErrors:");
104
+ for (const e of errors) console.log(` ! ${e}`);
105
+ process.exit(1);
106
+ }
107
+ console.log("\nDone. Restart opencode to activate the /buddy command.");
108
+ console.log("Then in opencode, try: /buddy");
109
+ }
110
+
111
+ export async function uninstall() {
112
+ const steps = [];
113
+ const errors = [];
114
+
115
+ // 1. Remove the command file
116
+ try {
117
+ await fs.unlink(COMMAND_FILE);
118
+ steps.push(`- ${COMMAND_FILE}`);
119
+ } catch (err) {
120
+ if (err.code === "ENOENT") steps.push(`= ${COMMAND_FILE} (not present)`);
121
+ else errors.push(`failed to remove ${COMMAND_FILE}: ${err.message}`);
122
+ }
123
+
124
+ // 2. Remove from opencode.json
125
+ try {
126
+ const raw = await fs.readFile(CONFIG_FILE, "utf8");
127
+ const config = JSON.parse(raw);
128
+ if (Array.isArray(config.plugin)) {
129
+ const before = config.plugin.length;
130
+ config.plugin = config.plugin.filter((p) => p !== PLUGIN_NAME);
131
+ if (config.plugin.length === 0) delete config.plugin;
132
+ if (config.plugin.length !== before) {
133
+ const tmp = CONFIG_FILE + ".tmp";
134
+ await fs.writeFile(tmp, JSON.stringify(config, null, 2));
135
+ await fs.rename(tmp, CONFIG_FILE);
136
+ steps.push(`~ ${CONFIG_FILE} (removed "${PLUGIN_NAME}")`);
137
+ } else {
138
+ steps.push(`= ${CONFIG_FILE} (no entry to remove)`);
139
+ }
140
+ } else {
141
+ steps.push(`= ${CONFIG_FILE} (no plugin array)`);
142
+ }
143
+ } catch (err) {
144
+ if (err.code === "ENOENT") steps.push(`= ${CONFIG_FILE} (not present)`);
145
+ else errors.push(`failed to patch ${CONFIG_FILE}: ${err.message}`);
146
+ }
147
+
148
+ console.log("opencode-buddy uninstall");
149
+ console.log("--------------------------");
150
+ for (const s of steps) console.log(` ${s}`);
151
+ if (errors.length) {
152
+ console.log("\nErrors:");
153
+ for (const e of errors) console.log(` ! ${e}`);
154
+ process.exit(1);
155
+ }
156
+ console.log("\nDone. Restart opencode to deactivate the /buddy command.");
157
+ }
@@ -0,0 +1,28 @@
1
+ // Persist buddy state to ~/.config/opencode-buddy/state.json
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import os from "node:os";
5
+
6
+ const DIR = path.join(os.homedir(), ".config", "opencode-buddy");
7
+ const FILE = path.join(DIR, "state.json");
8
+
9
+ export async function load() {
10
+ try {
11
+ const raw = await fs.readFile(FILE, "utf8");
12
+ return JSON.parse(raw);
13
+ } catch (err) {
14
+ if (err.code === "ENOENT") return null;
15
+ throw err;
16
+ }
17
+ }
18
+
19
+ export async function save(state) {
20
+ await fs.mkdir(DIR, { recursive: true });
21
+ const tmp = FILE + ".tmp";
22
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2));
23
+ await fs.rename(tmp, FILE);
24
+ }
25
+
26
+ export function pathForDisplay() {
27
+ return FILE;
28
+ }
package/src/plugin.js ADDED
@@ -0,0 +1,178 @@
1
+ // opencode plugin entry. Exposes a `buddy` tool the LLM can call and
2
+ // listens for session events to keep the buddy's mood in sync with what
3
+ // the user is actually doing.
4
+ //
5
+ // Loaded automatically by opencode when listed in `opencode.json`:
6
+ // { "plugin": ["opencode-buddy"] }
7
+ //
8
+ // Also installed at runtime by the `opencode-buddy install` CLI command,
9
+ // which writes ~/.config/opencode/commands/buddy.md (the /buddy slash
10
+ // command) and appends this package to opencode.json's plugin list.
11
+
12
+ import { tool } from "@opencode-ai/plugin";
13
+ import * as state from "./buddy/state.js";
14
+ import * as persistence from "./persistence.js";
15
+ import { SPECIES, paint, FLAVOR } from "./buddy/species.js";
16
+
17
+ const DESCRIPTION = `Interact with your virtual buddy companion. Pass an "action" argument.
18
+
19
+ Actions:
20
+ status - print current stats as a one-liner
21
+ feed - feed the buddy (+25 hunger, -5 energy)
22
+ play - play with the buddy (+20 happiness, -5 hunger, +5 xp)
23
+ rest - let the buddy rest (+30 energy)
24
+ switch <species> - change species (duck, cat, dragon, axolotl, robot, ghost)
25
+ rename <name> - rename the buddy (max 20 chars)
26
+ hatch [species] [name] - start a brand new buddy
27
+ ascii - return a rendered ASCII art of the current buddy
28
+ path - print path to the persistent state file
29
+ help - list available actions
30
+
31
+ The buddy's mood (idle/working/celebrating/scared/sleeping) is set
32
+ automatically based on the opencode session lifecycle. Use this tool
33
+ when the user asks things like "/buddy", "/buddy feed", "/buddy stats",
34
+ "check on my pet", "switch to a dragon", etc.`;
35
+
36
+ const SPECIES_LIST = SPECIES.join(", ");
37
+
38
+ function _summary(s) {
39
+ return `${s.name} the ${s.species} | Lv ${s.level} | ${s.state} | hunger ${Math.floor(s.hunger)} happiness ${Math.floor(s.happiness)} energy ${Math.floor(s.energy)} | xp ${s.xp}/${s.level * 50}`;
40
+ }
41
+
42
+ async function loadOrInit() {
43
+ const s = await persistence.load();
44
+ if (s) return s;
45
+ const fresh = { ...state.DEFAULT_STATE, lastTick: Date.now() };
46
+ await persistence.save(fresh);
47
+ return fresh;
48
+ }
49
+
50
+ export const BuddyPlugin = async ({ client }) => {
51
+ return {
52
+ // ----- Custom tool the LLM can call --------------------------------
53
+ tool: {
54
+ buddy: tool({
55
+ description: DESCRIPTION,
56
+ args: {
57
+ action: tool.schema
58
+ .string()
59
+ .describe(
60
+ `One of: status, feed, play, rest, switch, rename, hatch, ascii, path, help. For switch/rename/hatch, also pass the "species" or "name" argument as needed.`,
61
+ ),
62
+ species: tool.schema
63
+ .string()
64
+ .optional()
65
+ .describe(`Species for switch/hatch: ${SPECIES_LIST}`),
66
+ name: tool.schema
67
+ .string()
68
+ .optional()
69
+ .describe(`Name for rename/hatch (max 20 chars).`),
70
+ },
71
+ async execute(args) {
72
+ let s = await loadOrInit();
73
+ const action = (args.action || "").toLowerCase();
74
+ const species = (args.species || "").toLowerCase();
75
+ const name = args.name;
76
+
77
+ switch (action) {
78
+ case "":
79
+ case "status":
80
+ return _summary(s);
81
+
82
+ case "feed":
83
+ s = state.feed(s);
84
+ await persistence.save(s);
85
+ return `${s.name} munches happily. ${_summary(s)}`;
86
+
87
+ case "play":
88
+ s = state.play(s);
89
+ s = state.maybeLevelUp(s);
90
+ await persistence.save(s);
91
+ return `${s.name} plays! ${_summary(s)}`;
92
+
93
+ case "rest":
94
+ s = state.rest(s);
95
+ await persistence.save(s);
96
+ return `${s.name} curls up. ${_summary(s)}`;
97
+
98
+ case "switch": {
99
+ if (!species) return `Pick a species: ${SPECIES_LIST}`;
100
+ if (!SPECIES.includes(species))
101
+ return `Unknown species "${species}". Valid: ${SPECIES_LIST}`;
102
+ s = state.switchSpecies(s, species);
103
+ await persistence.save(s);
104
+ return `${s.name} transformed into a ${species}. ${_summary(s)}`;
105
+ }
106
+
107
+ case "rename": {
108
+ if (!name) return `Provide a name with the "name" argument.`;
109
+ s = state.rename(s, name);
110
+ await persistence.save(s);
111
+ return `Renamed to ${s.name}. ${_summary(s)}`;
112
+ }
113
+
114
+ case "hatch": {
115
+ const sp = species && SPECIES.includes(species) ? species : "duck";
116
+ s = {
117
+ ...state.DEFAULT_STATE,
118
+ species: sp,
119
+ name: name || "Buddy",
120
+ lastTick: Date.now(),
121
+ };
122
+ await persistence.save(s);
123
+ return `Hatched a new ${sp} named ${s.name}. ${_summary(s)}`;
124
+ }
125
+
126
+ case "ascii": {
127
+ const lines = paint(s.species, s.state);
128
+ return ["```", ...lines, "```", _summary(s)].join("\n");
129
+ }
130
+
131
+ case "path":
132
+ return persistence.pathForDisplay();
133
+
134
+ case "help":
135
+ return DESCRIPTION;
136
+
137
+ default:
138
+ return `Unknown action "${args.action}". Try: /buddy help`;
139
+ }
140
+ },
141
+ }),
142
+ },
143
+
144
+ // ----- React to opencode session lifecycle -------------------------
145
+ event: async ({ event }) => {
146
+ // Auto-mood: when opencode finishes a turn, the buddy celebrates
147
+ // for a few seconds. When a session errors, the buddy gets scared.
148
+ // The visible feedback comes from the tmux sidecar picking up the
149
+ // updated state file, or the LLM re-rendering ASCII via the tool.
150
+ try {
151
+ if (event.type === "session.idle") {
152
+ const s = await loadOrInit();
153
+ await persistence.save(state.celebrate(s));
154
+ } else if (event.type === "session.error") {
155
+ const s = await loadOrInit();
156
+ await persistence.save(state.scared(s));
157
+ }
158
+ } catch (err) {
159
+ if (client?.app?.log) {
160
+ await client.app.log({
161
+ body: {
162
+ service: "opencode-buddy",
163
+ level: "warn",
164
+ message: `event hook failed: ${err.message}`,
165
+ },
166
+ });
167
+ }
168
+ }
169
+ },
170
+ };
171
+ };
172
+
173
+ // Re-export common helpers so other tools (e.g. custom agents) can
174
+ // import the same state machine used by the plugin and the tmux
175
+ // sidecar — guaranteeing the persisted state file format stays in sync.
176
+ export { SPECIES, FLAVOR };
177
+ export * as stateMachine from "./buddy/state.js";
178
+ export { persistence };