@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.
- package/README.md +34 -2
- package/index.ts +11 -0
- package/package.json +28 -5
- package/tui.js +232 -0
package/README.md
CHANGED
|
@@ -1,3 +1,35 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rizal72/oc-coffee
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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
|
+
});
|