@rizal72/oc-coffee 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +34 -2
  2. package/index.ts +11 -0
  3. package/package.json +28 -5
  4. package/tui.js +232 -0
package/README.md CHANGED
@@ -1,3 +1,35 @@
1
- # Temporary Holding Version
1
+ # @rizal72/oc-coffee
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Keeps the Mac awake while the agent is working, and lets it sleep between turns.
4
+
5
+ A cup in the prompt footer shows the state — green while a run is active — and `/coffee` cycles the
6
+ level.
7
+
8
+ **macOS only**: it drives `caffeinate`. Elsewhere the plugin loads and the badge stays off.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ opencode plugin add @rizal72/oc-coffee
14
+ ```
15
+
16
+ Or by hand, one line in `~/.config/opencode/opencode.jsonc`:
17
+
18
+ ```jsonc
19
+ "plugins": ["@rizal72/oc-coffee"]
20
+ ```
21
+
22
+ ## Use
23
+
24
+ | level | what is blocked |
25
+ |---|---|
26
+ | `off` | nothing |
27
+ | `idle` | idle sleep — `caffeinate -i` |
28
+ | `display` | idle and display sleep — `-i -d` |
29
+ | `system` | everything, and the user is declared active — `-i -d -s -u` |
30
+
31
+ The assertion lives only while a run is active: it is engaged when the agent starts and released when
32
+ it succeeds, fails or is interrupted. It is bound to the TUI's own process (`caffeinate -w`), so it
33
+ dies with the window however the window closes — a closed terminal cannot leave the machine awake.
34
+
35
+ Requires OpenCode v2 and macOS. TypeScript, no build step, no runtime dependencies.
package/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ // oc-coffee — server half.
2
+ //
3
+ // Deliberately empty: everything happens in the TUI half. It exists because the loader
4
+ // resolves a plugin directory by entry file names (`index`/`server`, `tui`, `rpc`) and a
5
+ // missing server entry fails the whole load.
6
+ export default {
7
+ id: "oc-coffee",
8
+ setup() {
9
+ // nothing to do on the server
10
+ },
11
+ }
package/package.json CHANGED
@@ -1,6 +1,29 @@
1
1
  {
2
- "name": "@rizal72/oc-coffee",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
2
+ "name": "@rizal72/oc-coffee",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "description": "Keeps the Mac awake while the agent works: cup badge in the footer, /coffee command",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/rizal72/opencode-plugins.git"
9
+ },
10
+ "license": "MIT",
11
+ "files": [
12
+ "*.ts",
13
+ "tui.js",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "opencode",
18
+ "opencode-plugin",
19
+ "macos",
20
+ "caffeinate"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "exports": {
26
+ ".": "./index.ts",
27
+ "./tui": "./tui.js"
28
+ }
29
+ }
package/tui.js ADDED
@@ -0,0 +1,232 @@
1
+ import { createComponent as _$createComponent } from "@opentui/solid";
2
+ import { effect as _$effect } from "@opentui/solid";
3
+ import { insert as _$insert } from "@opentui/solid";
4
+ import { createTextNode as _$createTextNode } from "@opentui/solid";
5
+ import { insertNode as _$insertNode } from "@opentui/solid";
6
+ import { setProp as _$setProp } from "@opentui/solid";
7
+ import { createElement as _$createElement } from "@opentui/solid";
8
+ // oc-coffee — TUI half.
9
+ //
10
+ // Keeps the Mac awake while the agent works, the port of pi's `pi-coffee`.
11
+ //
12
+ // off | idle | display | system -> caffeinate (), (-i), (-i -d), (-i -d -s -u)
13
+ //
14
+ // Four cumulative levels. The assertion is held only while a run is active — engaged on
15
+ // `session.execution.started`, released on `.succeeded` / `.failed` / `.interrupted` — so the
16
+ // Mac sleeps normally between turns. `-w <pid>` binds the child to this process, so it dies
17
+ // with the TUI however the TUI exits, SIGKILL included, and a closed window can never leave
18
+ // the machine awake for ever.
19
+ //
20
+ // The assertion lives here and not in the server because the server cannot see a run: the
21
+ // public event stream carries only catalogue events (`model.updated`, `provider.updated`, …)
22
+ // and there is no "session is executing" state to read. Measured 2026-10-16, including a
23
+ // clean headless run. The TUI is the only half that knows.
24
+ //
25
+ // The badge shows the level whenever it is not `off` — the setting, not the moment — and only
26
+ // the cup lights up (green) while the assertion is up, so the state is one mark and the level
27
+ // stays legible. `/coffee` opens the menu, `/coffee <level>` sets one, `/coffee status` reports.
28
+ // The level is persisted with the TUI's own durable storage, which replaces pi's
29
+ // `pi-coffee.json`; there is no file of ours.
30
+ //
31
+ // macOS only: elsewhere the plugin stays loaded and simply never engages.
32
+ import { Plugin } from "@opencode/plugin/tui";
33
+ import { spawn } from "node:child_process";
34
+ import { createRoot, createSignal } from "solid-js";
35
+
36
+ /** md-coffee_outline (U+F06CA), the glyph pi's own badge uses. */
37
+ const CUP_GLYPH = "\u{f06ca}";
38
+ const LEVELS = ["off", "idle", "display", "system"];
39
+ const LEVEL_FLAGS = {
40
+ off: [],
41
+ idle: ["-i"],
42
+ display: ["-i", "-d"],
43
+ system: ["-i", "-d", "-s", "-u"]
44
+ };
45
+
46
+ /** The four menu rows, the wording pi uses. */
47
+ const LEVEL_MENU = {
48
+ off: "Off — Do not prevent any sleep",
49
+ idle: "Prevent Idle Sleep — Keep the system awake while the agent works (caffeinate -i)",
50
+ display: "Prevent Display Sleep — Also keep the display from idle-sleeping (caffeinate -i -d)",
51
+ system: "Prevent System Sleep — Also block all system sleep on AC and declare the user active (caffeinate -i -d -s -u)"
52
+ };
53
+
54
+ /** Fallbacks, used only when the theme defines none of the candidates. */
55
+ const FALLBACK_GREEN = "#3fb950";
56
+ const FALLBACK_DIM = "#8b949e";
57
+ function isLevel(value) {
58
+ return typeof value === "string" && LEVELS.includes(value);
59
+ }
60
+ export default Plugin.define({
61
+ id: "oc-coffee.tui",
62
+ setup(context) {
63
+ const supported = process.platform === "darwin";
64
+
65
+ // Durable: survives a restart and is shared by every TUI. A JSON object is what the
66
+ // storage takes, so the level rides in one.
67
+ const [stored, updateStored] = context.storage.store("coffee", {
68
+ initial: {
69
+ level: "display"
70
+ }
71
+ });
72
+ const level = () => isLevel(stored.level) ? stored.level : "display";
73
+ const themeColour = (...paths) => {
74
+ for (const path of paths) {
75
+ let node = context.theme;
76
+ for (const key of path) node = node?.[key];
77
+ if (node !== undefined && node !== null) return node;
78
+ }
79
+ return undefined;
80
+ };
81
+ const GREEN = themeColour(["syntax", "string"], ["diff", "text", "added"], ["text", "feedback", "success", "base"]) ?? FALLBACK_GREEN;
82
+ const DIM = themeColour(["text", "muted"], ["text", "faint"]) ?? FALLBACK_DIM;
83
+ let child = null;
84
+ /** Runs in flight: several sessions can execute at once, so it is a count, not a flag. */
85
+ let running = 0;
86
+ /** Reactive: the badge must repaint the moment the assertion goes up or down. A plain
87
+ * variable read in the render would be read once and never again. */
88
+ const [active, setActive] = createRoot(() => createSignal(false));
89
+ function engage() {
90
+ if (!supported || level() === "off" || child) return;
91
+ const spawned = spawn("caffeinate", [...LEVEL_FLAGS[level()], "-w", String(process.pid)], {
92
+ stdio: "ignore"
93
+ });
94
+ child = spawned;
95
+ setActive(true);
96
+ spawned.on("error", () => {
97
+ if (child !== spawned) return;
98
+ child = null;
99
+ setActive(false);
100
+ });
101
+ spawned.on("exit", () => {
102
+ if (child !== spawned) return;
103
+ child = null;
104
+ setActive(false);
105
+ });
106
+ }
107
+ function release() {
108
+ const held = child;
109
+ child = null;
110
+ setActive(false);
111
+ held?.kill("SIGTERM");
112
+ }
113
+
114
+ /** The level can only change from the command, where a failure is worth a word. */
115
+ async function setLevel(next) {
116
+ const wasUp = child !== null;
117
+ if (wasUp) release();
118
+ await updateStored(draft => {
119
+ draft.level = next;
120
+ });
121
+ if (wasUp || running > 0) engage();
122
+ }
123
+ const describe = () => {
124
+ const flags = LEVEL_FLAGS[level()];
125
+ const labels = flags.length === 0 ? "Sleep prevention: off" : `Sleep prevention: ${level()} (caffeinate ${flags.join(" ")})`;
126
+ return `${labels} — assertion ${child ? "up" : "idle"}`;
127
+ };
128
+
129
+ // One listener per boundary. `running` counts sessions executing right now, so the
130
+ // assertion is up while it is positive and the level is not `off`.
131
+ const stopStarted = context.data.on("session.execution.started", () => {
132
+ running += 1;
133
+ if (running === 1) engage();
134
+ });
135
+ const stopSucceeded = context.data.on("session.execution.succeeded", () => {
136
+ running = Math.max(0, running - 1);
137
+ if (running === 0) release();
138
+ });
139
+ const stopFailed = context.data.on("session.execution.failed", () => {
140
+ running = Math.max(0, running - 1);
141
+ if (running === 0) release();
142
+ });
143
+ const stopInterrupted = context.data.on("session.execution.interrupted", () => {
144
+ running = Math.max(0, running - 1);
145
+ if (running === 0) release();
146
+ });
147
+
148
+ /** Always visible, `off` included: the badge is the setting, and seeing `off` is how you
149
+ * know the plugin is loaded and deliberately doing nothing. (pi hides it when off; the
150
+ * owner asked for the name instead.) */
151
+ const Badge = () => (() => {
152
+ var _el$ = _$createElement("box"),
153
+ _el$2 = _$createElement("text"),
154
+ _el$4 = _$createElement("text"),
155
+ _el$5 = _$createTextNode(` `);
156
+ _$insertNode(_el$, _el$2);
157
+ _$insertNode(_el$, _el$4);
158
+ _$setProp(_el$, "flexDirection", "row");
159
+ _$setProp(_el$, "flexShrink", 0);
160
+ _$insertNode(_el$2, _$createTextNode(`󰛊`));
161
+ _$setProp(_el$2, "wrapMode", "none");
162
+ _$insertNode(_el$4, _el$5);
163
+ _$setProp(_el$4, "fg", DIM);
164
+ _$setProp(_el$4, "wrapMode", "none");
165
+ _$insert(_el$4, level, null);
166
+ _$effect(_$p => _$setProp(_el$2, "fg", active() ? GREEN : DIM, _$p));
167
+ return _el$;
168
+ })();
169
+ context.ui.slot({
170
+ append: "prompt.footer.status",
171
+ render: () => _$createComponent(Badge, {})
172
+ });
173
+ context.keymap.layer(() => ({
174
+ mode: "global",
175
+ commands: [{
176
+ id: "oc-coffee.menu",
177
+ title: "Coffee: sleep prevention",
178
+ group: "Coffee",
179
+ palette: true,
180
+ slash: {
181
+ name: "coffee",
182
+ arguments: true
183
+ },
184
+ run: async input => {
185
+ const argument = (input ?? "").trim().split(/\s+/).filter(Boolean)[0];
186
+ if (argument === "status") {
187
+ await context.ui.dialog.alert({
188
+ title: `${CUP_GLYPH} Coffee`,
189
+ message: describe()
190
+ });
191
+ return;
192
+ }
193
+ if (isLevel(argument)) {
194
+ await setLevel(argument);
195
+ context.ui.toast.show({
196
+ message: describe()
197
+ });
198
+ return;
199
+ }
200
+ if (argument !== undefined) {
201
+ context.ui.toast.show({
202
+ message: `usage: /coffee [${LEVELS.join(" | ")} | status]`,
203
+ variant: "warning"
204
+ });
205
+ return;
206
+ }
207
+ const picked = await context.ui.dialog.select({
208
+ title: `${CUP_GLYPH} Sleep prevention`,
209
+ current: level(),
210
+ options: LEVELS.map(candidate => ({
211
+ title: candidate === level() ? `${LEVEL_MENU[candidate]}` : LEVEL_MENU[candidate],
212
+ value: candidate
213
+ }))
214
+ });
215
+ if (picked) {
216
+ await setLevel(String(picked));
217
+ context.ui.toast.show({
218
+ message: describe()
219
+ });
220
+ }
221
+ }
222
+ }]
223
+ }));
224
+ return () => {
225
+ stopStarted?.();
226
+ stopSucceeded?.();
227
+ stopFailed?.();
228
+ stopInterrupted?.();
229
+ release();
230
+ };
231
+ }
232
+ });